#!/usr/bin/python

#    This file is part of PTZ.
#
#    PTZ is free software: you can redistribute it and/or modify
#    it under the terms of the GNU General Public License as published by
#    the Free Software Foundation, either version 3 of the License, or
#    (at your option) any later version.
#
#    PTZ is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU General Public License for more details.
#
#    You should have received a copy of the GNU General Public License
#    along with PTZ.  If not, see <http://www.gnu.org/licenses/>.
#
#    Copyright 2012  Jeremy Mortis (mortis@tansay.ca)

import Tkinter
import Tkconstants 
import tkMessageBox
import tkSimpleDialog
import Image
import ImageTk
import time
import sys
import os
import logging
import ConfigParser
import re
import webbrowser
import collections
import socket

class MainWindow(Tkinter.Tk):

    def __init__(self):

        Tkinter.Tk.__init__(self)

        if sys.platform == 'win32':
            self.wm_iconbitmap("ptz.ico")

        self.protocol("WM_DELETE_WINDOW", self.delete_window)
        self.resizable(width=False, height=False)

        self.device = None
        self.startupReset = Tkinter.IntVar()
        self.prevMouse = (0, 0)
        self.boxPoint = (0, 0)

        self.title("PTZ")
        self.build_menu()

        log("Getting configuration")
        self.get_config()

        self.topFrame = Tkinter.Frame(self)
        self.topFrame.pack()

        self.mousepadCanvas = Tkinter.Canvas(self.topFrame, width=500, height=150, cursor="fleur", bg="white", bd=2, relief=Tkconstants.RAISED)
        self.mousepadCanvas.bind("<Button-1>", self.mouseCallback)
        self.mousepadCanvas.bind("<B1-Motion>", self.mouseCallback)
        self.mousepadCanvas.bind("<Button-4>", self.mouseWheelCallback)  # Linux
        self.mousepadCanvas.bind("<Button-5>", self.mouseWheelCallback)  # Linux
        self.bind("<MouseWheel>", self.mouseWheelCallback)               # Windows
        self.bind("<Key>", self.keypressCallback)
        self.mousepadCanvas.pack(side=Tkconstants.LEFT, padx=5, pady=5)

        self.box = None
        self.showBox()

        self.zoomFrame = Tkinter.Frame(self.topFrame)
        self.zoomFrame.pack(side=Tkconstants.RIGHT, padx=10, pady=10)

        self.zoominButton = Tkinter.Button(self.zoomFrame, text="+", command=self.process_zoomin_command)
        self.zoominButton.pack(fill=Tkconstants.X)

        self.zoomLabel = Tkinter.Label(self.zoomFrame, text="Zoom")
        self.zoomLabel.pack()

        self.zoomoutButton = Tkinter.Button(self.zoomFrame, text="-", command=self.process_zoomout_command)
        self.zoomoutButton.pack(fill=Tkconstants.X)

        self.statusFrame = Tkinter.Frame(self, bd=1, relief=Tkinter.SUNKEN)
        self.statusFrame.pack(side=Tkinter.BOTTOM, fill=Tkinter.X)

        self.coordBar = Tkinter.Label(self.statusFrame, text="",  anchor=Tkinter.W)
        self.coordBar.pack(side=Tkinter.LEFT)

        self.statusBar = Tkinter.Label(self.statusFrame, text="Not connected",  anchor=Tkinter.E)
        self.statusBar.pack(side=Tkinter.RIGHT)

        self.connect_device()

        log("Main window started")

    def get_config(self):

        self.configDefaults = {"ipaddress": "192.168.1.77",
                               "pan": 0, 
                               "tilt": 0, 
                               "zoom": 0, 
                               "startupreset": 0, 
                               "zoomin": "5:abcdef",
                               "zoomout": "5:123456",
                               "recordcode": "5:abcdef",
                               "extdisplaycode": "5:abcdef",
                               "minPan": -45,
                               "maxPan": 45,
                               "panIncrement": 1,
                               "minTilt": -15,
                               "maxTilt": 15,
                               "tiltIncrement": 0.2,
                               }
        self.config = ConfigParser.RawConfigParser(self.configDefaults)

        if sys.platform == 'win32':
                self.configFilename = os.path.join(os.environ['PROGRAMDATA'], "ptz", "ptz.ini")
        else:
            self.configFilename = os.path.expanduser(os.path.join("~", ".ptz"))

        f = self.config.read(self.configFilename)
        
        if self.config.has_option("DEFAULT", "geometry"):
            try:
                g = self.config.get("DEFAULT", "geometry")
                (x, y) = re.compile(r"^\d+x\d+\+(\d+)\+(\d+)$").match(g).groups()
                self.geometry("+%s+%s" % (x, y))
            except:
                log("Failed to parse geometry in config file: %s" % g) 

        self.ipAddress = self.config.get("DEFAULT", "ipaddress")

        self.startupReset.set(self.config.getint("DEFAULT", "startupreset"))
        if self.startupReset.get():
            self.pan = 0
            self.tilt = 0
            self.zoom = 0
        else:
            self.pan = self.config.getfloat("DEFAULT", "pan")
            self.tilt = self.config.getfloat("DEFAULT", "tilt")
            self.zoom = self.config.getint("DEFAULT", "zoom")

        self.zoomin = self.config.get("DEFAULT", "zoomin")
        self.zoomout = self.config.get("DEFAULT", "zoomout")
        self.recordcode = self.config.get("DEFAULT", "recordcode")
        self.extdisplaycode = self.config.get("DEFAULT", "extdisplaycode")
        self.minPan = self.config.getfloat("DEFAULT", "minpan")
        self.maxPan = self.config.getfloat("DEFAULT", "maxpan")
        self.panIncrement = self.config.getfloat("DEFAULT", "panincrement")
        self.minTilt = self.config.getfloat("DEFAULT", "mintilt")
        self.maxTilt = self.config.getfloat("DEFAULT", "maxtilt")
        self.tiltIncrement = self.config.getfloat("DEFAULT", "tiltincrement")
            
    def build_menu(self, *args):

        self.menu = Tkinter.Menu(self)

        self.menuFile = Tkinter.Menu(self.menu, tearoff=0)
        self.menu.add_cascade(label="File", menu=self.menuFile, underline=0)
        self.menuFile.add_command(label="Quit", command=self.delete_window, underline=0) 

        self.menuConfig = Tkinter.Menu(self.menu, tearoff=0)
        self.menu.add_cascade(label="Config", menu=self.menuConfig, underline=0)
        self.menuConfig.add_command(label="Remote codes", command=self.show_codes_dialog, underline=0) 
        self.menuConfig.add_command(label="Pan/Tilt limits", command=self.show_limits_dialog, underline=0) 
        self.menuConfig.add("checkbutton", label="Reset position on startup", variable=self.startupReset, underline=6) 
        self.menuConfig.add_command(label="Device", command=self.show_device_dialog, underline=0) 

        self.menuCamera = Tkinter.Menu(self.menu, tearoff=0)
        self.menu.add_cascade(label="Camera", menu=self.menuCamera, underline=1)
        self.menuCamera.add_command(label="Zoom in", command=self.process_zoomin_command, underline=5) 
        self.menuCamera.add_command(label="Zoom out", command=self.process_zoomout_command, underline=5) 
        self.menuCamera.add_command(label="External display", command=self.process_extdisplay_command, underline=1) 
        self.menuCamera.add_command(label="Record", command=self.process_record_command, underline=0) 

        self.menuHelp = Tkinter.Menu(self.menu, tearoff=0)
        self.menu.add_cascade(label="Help", menu=self.menuHelp, underline=0)
        self.menuHelp.add_command(label="Wiki", command=self.show_wiki, underline=0)
        self.menuHelp.add_command(label="About", command=self.show_infobox, underline=0)

        self.configure(menu=self.menu)

    def show_device_dialog(self):
        s = tkSimpleDialog.askstring("IP Address", "", initialvalue=self.ipAddress)
        if s != self.ipAddress:
            log("IP address changed to: %s" % s)
            self.ipAddress = s
            self.connect_device()

    def show_codes_dialog(self):
        s = CodesDialog(self, title="Configure Remote Codes")

    def show_limits_dialog(self):
        s = LimitsDialog(self, title="Configure Pan/Tilt Limits")

    def show_infobox(self):

        try:
            version = ('.'.join(open("version.txt").readlines())).translate(None, "\r\n")
        except:
            version = '(Unknown version)'

        tkMessageBox.showinfo("", ("PTZ %s\n\n" % version) 
                              + "Copyright 2012 by Jeremy Mortis (mortis@tansay.ca)\n\n"
                              + "This program is free software made available under the GPLv3 license.")

    def show_wiki(self):
        webbrowser.open("https://code.google.com/p/ptz")

    def mouseCallback(self, event):

        if event.type == '4':  # mouse down
            self.prevMouse = (event.x, event.y)
            return

        newX = self.boxPoint[0] + event.x - self.prevMouse[0] 
        prevPan = self.pan
        self.pan = scale(newX, 0.0, float(self.mousepadCanvas["width"]), self.minPan, self.maxPan)
        if self.pan != prevPan:
            self.process_pan_command()

        newY = self.boxPoint[1] + event.y - self.prevMouse[1] 
        prevTilt = self.tilt
        self.tilt = scale(newY, 0.0, float(self.mousepadCanvas["height"]), self.minTilt, self.maxTilt)
        if self.tilt != prevTilt:
            self.process_tilt_command()

        self.prevMouse = (event.x, event.y)
    
    def mouseWheelCallback(self, event):
        # Linux uses event.num, Windows uses event.delta

        log("Mousewheel %s %s" % (event.num, event.delta))

        if event.num == 4 or event.delta > 0:
            self.process_zoomin_command()
        else:
            self.process_zoomout_command()
            
    def keypressCallback(self, event):
        log("key: %s" % event.keysym)

        if event.keysym.lower() == "left":
            self.pan = max(self.pan - self.panIncrement, self.minPan)
            self.process_pan_command()
        elif event.keysym.lower() == "right":
            self.pan = min(self.pan + self.panIncrement, self.maxPan)
            self.process_pan_command()
        elif event.keysym.lower() == "up":
            self.tilt = max(self.tilt - self.tiltIncrement, self.minTilt)
            self.process_tilt_command()
        elif event.keysym.lower() == "down":
            self.tilt = min(self.tilt + self.tiltIncrement, self.maxTilt)
            self.process_tilt_command()
        elif event.keysym.lower() in ["plus", "kp_add"]:
            self.process_zoomin_command()
        elif event.keysym.lower() in ["minus", "kp_subtract"]:
            self.process_zoomout_command()

    def updateStatusBar(self):
        s = "%0.1f:%0.1f z%i" % (self.pan, self.tilt, self.zoom)
        self.coordBar.configure(text=s)

        if self.device:
            s = "Connected to %s" % self.ipAddress
        else:
            s = "Not connected"
        self.statusBar.configure(text=s)


    def showBox(self):

        zoomFactor = 4 / pow(1.05, self.zoom) 

        x = scale(self.pan, self.minPan, self.maxPan, 0, float(self.mousepadCanvas["width"]))
        y = scale(self.tilt, self.minTilt, self.maxTilt, 0, float(self.mousepadCanvas["height"]))
        self.boxPoint = (x, y)

        x1 = x - zoomFactor * 16
        x2 = x + zoomFactor * 16

        y1 = y - zoomFactor * 9
        y2 = y + zoomFactor * 9

        if not self.box:
            self.box = self.mousepadCanvas.create_rectangle(x1, y1, x2, y2, fill="#CCCCCC", stipple="gray25")
        else:
            self.mousepadCanvas.coords(self.box, x1, y1, x2, y2)

    def process_pan_command(self, *args):

        panUnits = scale(self.pan, -90,  +90, 544, 2400)
        self.converse("p=%iu" % int(panUnits))
        self.showBox()
        self.updateStatusBar()

    def process_tilt_command(self, *args):

        tiltUnits = scale(-self.tilt, -90,  +90, 544, 2400)
        self.converse("t=%iu" % int(tiltUnits))
        self.showBox()
        self.updateStatusBar()

    def process_zoomin_command(self, *args):

        self.converse("e " + self.zoomin)
        self.zoom += 1
        self.showBox()
        self.updateStatusBar()

    def process_zoomout_command(self, *args):

        self.converse("e " + self.zoomout)
        if self.zoom > 0:
            self.zoom -= 1
        self.showBox()
        self.updateStatusBar()

    def process_record_command(self, *args):

        self.converse("e " + self.recordcode)

    def process_extdisplay_command(self, *args):

        self.converse("e " + self.extdisplaycode)

    def delete_window(self):

        self.save_config()
        self.quit()

    def connect_device(self):

        if self.ipAddress == "":
            tkMessageBox.showwarning("No device selected", "Please select a device using the device menu")
            return

        if self.device:
            self.device.close()
            self.device = None

        if ':' in self.ipAddress:
            ipNumber, ipPort = self.ipAddress.split(':')
        else:
            ipNumber = self.ipAddress
            ipPort = 23

        try:
            log("Connecting to: %s" % self.ipAddress)
            self.device = socket.create_connection((ipNumber, int(ipPort)), 3)
            self.device.settimeout(1.0)
            self.converse("")
        except Exception, e:
            log("Exception: %s" % str(e))
            self.device = None
            tkMessageBox.showerror("I/O Error on Open", "Unable to connect to remote device %s" % self.ipAddress)

        self.updateStatusBar()

    def converse(self, command):

        log("Converse: %s" % command)

        if not self.device:
            self.connect_device()

        if not self.device:
            return

        try:
            self.device.sendall(command + "\r\n")
            line = ""
            while True:
                s = str(self.device.recv(1024))
                line += s
                if line.endswith("\r\n> "):
                    break
        except Exception, e:
            log("Exception: %s" % str(e))
            tkMessageBox.showerror("Error", "Device %s unreachable" % self.varIpAddress.get())
            self.device = None
        
        log("Response: %s" % line)

    def save_config(self):

        self.config.set("DEFAULT", "geometry", self.geometry())
        self.config.set("DEFAULT", "pan", self.pan)
        self.config.set("DEFAULT", "tilt", self.tilt)
        self.config.set("DEFAULT", "zoom", self.zoom)
        self.config.set("DEFAULT", "startupreset", self.startupReset.get())
        self.config.set("DEFAULT", "ipaddress", self.ipAddress)
        self.config.set("DEFAULT", "zoomin", self.zoomin)
        self.config.set("DEFAULT", "zoomout", self.zoomout)
        self.config.set("DEFAULT", "recordcode", self.recordcode)
        self.config.set("DEFAULT", "extdisplaycode", self.extdisplaycode)
        self.config.set("DEFAULT", "minpan", self.minPan)
        self.config.set("DEFAULT", "maxpan", self.maxPan)
        self.config.set("DEFAULT", "panincrement", self.panIncrement)
        self.config.set("DEFAULT", "mintilt", self.minTilt)
        self.config.set("DEFAULT", "maxTilt", self.maxTilt)
        self.config.set("DEFAULT", "tiltincrement", self.tiltIncrement)

        with open(self.configFilename, 'wb') as configfile:
            self.config.write(configfile)        

class CodesDialog(tkSimpleDialog.Dialog):

    def body(self, master):

        Tkinter.Label(master, text="Zoom in:").grid(row=0, column=0, sticky=Tkinter.W)
        self.varZoomin = Tkinter.StringVar()
        self.varZoomin.set(self.parent.zoomin)
        self.entryZoomin = Tkinter.Entry(master, textvariable=self.varZoomin)
        self.entryZoomin.grid(row=0, column=1)

        Tkinter.Label(master, text="Zoom out:").grid(row=1, column=0, sticky=Tkinter.W)
        self.varZoomout = Tkinter.StringVar()
        self.varZoomout.set(self.parent.zoomout)
        self.entryZoomout = Tkinter.Entry(master, textvariable=self.varZoomout)
        self.entryZoomout.grid(row=1, column=1)

        Tkinter.Label(master, text="External Display:").grid(row=2, column=0, sticky=Tkinter.W)
        self.varExtdisplaycode = Tkinter.StringVar()
        self.varExtdisplaycode.set(self.parent.extdisplaycode)
        self.entryExtdisplaycode = Tkinter.Entry(master, textvariable=self.varExtdisplaycode)
        self.entryExtdisplaycode.grid(row=2, column=1)

        Tkinter.Label(master, text="Record:").grid(row=3, column=0, sticky=Tkinter.W)
        self.varRecordcode = Tkinter.StringVar()
        self.varRecordcode.set(self.parent.recordcode)
        self.entryRecordcode = Tkinter.Entry(master, textvariable=self.varRecordcode)
        self.entryRecordcode.grid(row=3, column=1)

    def apply(self):
        self.parent.zoomin = self.varZoomin.get()
        self.parent.zoomout = self.varZoomout.get()
        self.parent.extdisplaycode = self.varExtdisplaycode.get()
        self.parent.recordcode = self.varRecordcode.get()


class LimitsDialog(tkSimpleDialog.Dialog):

    def body(self, master):

        row = 0
        Tkinter.Label(master, text="Minimum pan angle:").grid(row=row, column=0, sticky=Tkinter.W)
        self.varMinPan = Tkinter.DoubleVar()
        self.varMinPan.set(self.parent.minPan)
        self.entryMinPan = Tkinter.Entry(master, textvariable=self.varMinPan)
        self.entryMinPan.grid(row=row, column=1)

        row += 1
        Tkinter.Label(master, text="Maximum pan angle:").grid(row=row, column=0, sticky=Tkinter.W)
        self.varMaxPan = Tkinter.DoubleVar()
        self.varMaxPan.set(self.parent.maxPan)
        self.entryMaxPan = Tkinter.Entry(master, textvariable=self.varMaxPan)
        self.entryMaxPan.grid(row=row, column=1)

        row += 1
        Tkinter.Label(master, text="Pan angle increment:").grid(row=row, column=0, sticky=Tkinter.W)
        self.varPanIncrement = Tkinter.DoubleVar()
        self.varPanIncrement.set(self.parent.panIncrement)
        self.entryPanIncrement = Tkinter.Entry(master, textvariable=self.varPanIncrement)
        self.entryPanIncrement.grid(row=row, column=1)

        row += 1
        Tkinter.Label(master, text="Minimum tilt angle:").grid(row=row, column=0, sticky=Tkinter.W)
        self.varMinTilt = Tkinter.DoubleVar()
        self.varMinTilt.set(self.parent.minTilt)
        self.entryMinTilt = Tkinter.Entry(master, textvariable=self.varMinTilt)
        self.entryMinTilt.grid(row=row, column=1)

        row += 1
        Tkinter.Label(master, text="Maximum tilt angle:").grid(row=row, column=0, sticky=Tkinter.W)
        self.varMaxTilt = Tkinter.DoubleVar()
        self.varMaxTilt.set(self.parent.maxTilt)
        self.entryMaxTilt = Tkinter.Entry(master, textvariable=self.varMaxTilt)
        self.entryMaxTilt.grid(row=row, column=1)

        row += 1
        Tkinter.Label(master, text="Tilt angle increment:").grid(row=row, column=0, sticky=Tkinter.W)
        self.varTiltIncrement = Tkinter.DoubleVar()
        self.varTiltIncrement.set(self.parent.tiltIncrement)
        self.entryTiltIncrement = Tkinter.Entry(master, textvariable=self.varTiltIncrement)
        self.entryTiltIncrement.grid(row=row, column=1)

    def apply(self):
        self.parent.minPan = self.varMinPan.get()
        self.parent.maxPan = self.varMaxPan.get()
        self.parent.panIncrement = self.varPanIncrement.get()
        self.parent.minTilt = self.varMinTilt.get()
        self.parent.maxTilt = self.varMaxTilt.get()
        self.parent.tiltIncrement = self.varTiltIncrement.get()


def log(s):
    if hasattr(sys.stdout, "isatty"):
        if sys.stdout.isatty():
            sys.stdout.write("%s\n" % s)
    logging.info(s)

def scale(v, a1, a2, b1, b2):

    if v < a1:
        v = a1
    else:
        if v > a2:
            v = a2

    return (float(v) - a1)/(a2 - a1) * (b2 - b1) + b1

if __name__ == '__main__':

    sys.stderr = sys.stdout

    if sys.platform == 'win32':
        logFilename = os.path.join(os.environ['PROGRAMDATA'], "ptz", "ptz.log")
    else:
        logFilename = os.path.expanduser(os.path.join("~", "ptz.log"))

    logging.basicConfig(filename=logFilename, format='%(asctime)s %(message)s', level=logging.INFO)
    log("Application started")

    mainWindow = MainWindow()

    log("Launching mainloop")
    mainWindow.mainloop()

    mainWindow.destroy()

    log("Application terminated")


